Skip to content

feat(client-generator)!: self-contained generator folders, module runtime, and eject as source - #3047

Draft
RomanHotsiy wants to merge 36 commits into
feat/agent-friendlyfrom
docs/generator-rewrite-adrs
Draft

feat(client-generator)!: self-contained generator folders, module runtime, and eject as source#3047
RomanHotsiy wants to merge 36 commits into
feat/agent-friendlyfrom
docs/generator-rewrite-adrs

Conversation

@RomanHotsiy

@RomanHotsiy RomanHotsiy commented Aug 21, 2026

Copy link
Copy Markdown
Member

What/Why/How?

Design docs only — no code changes. Records the decisions behind the planned
generator rewrite before any of it is implemented, plus the measured analysis
those decisions rest on.

Three ADRs

  • [ADR-0020] Self-contained generator folders, ejected as source. Today python/go/php eject as their own type-stripped source, while the seven TypeScript-family generators esbuild-bundle ~24 modules — the ejected typescript.mjs is 178 kB opening with __defProp shims and inlining copies of authoring/ code that is already public API. Every generator becomes one folder with the same stage skeleton, ejected as .ts — including the single-file generators, which are re-grouped into the same stages rather than grandfathered. Self-containment was never the goal on its own; leaving a 953-line python/index.ts and a 1169-line go/index.ts whole would keep the very asymmetry this removes. Their existing functions sort into the stages as they are, so it is a re-grouping and not a rewrite. The .replaceAll("'../../authoring/index.js'", …) import rewrite is replaced by real package specifiers plus a guard test.
  • [ADR-0021] Text printers — one common printer plus one per language. Supersedes ADR-0001, which still specifies ts.factory AST codegen; emitters/ts.ts and emitters/package-client.ts no longer exist and every generator emits text. The boundary is syntax vs shape: the printer owns identifier safety, escaping, literals, comments and layout; the generator writes classes and signatures as template literals so the emitted code stays readable in the file the user owns.
  • [ADR-0022] Runtime is inline or a sibling module; package mode removed. Amends ADR-0017 point 3. Package mode is the sole reason entry-weight.test.ts exists, it makes the TypeScript runtime dual-purpose, and it silently ignores an ejected runtime the user edited. runtime: 'module' serves its actual purpose — deduplication — while staying zero-dependency.

The analysis (docs/helper-surface.md) is a point-in-time inventory: 87 files, 15,913 lines, 183 exported values, measured by reachability, direct symbol use, and toolkit use.

Two findings drove the decisions:

  • authoring/ is not a neutral toolkit in practice — it is the non-TypeScript toolkit. Ten of its sixteen helpers have exactly three consumers, always go/php/python. No TypeScript-family generator uses Printer, docText, identifierFor, or any schema-shape helper; TypeScript keeps a complete shadow implementation in emitters/.
  • The seven TypeScript generators share four functions totalling 27 lines (safeIdent, pascalCase, codeLiteral, codeString). The "large shared TypeScript emitter layer" is mostly the typescript generator's own body living in a shared directory.

Twelve duplications are catalogued with file:line evidence. Three are defects, not untidiness:

  • Two TypeScript string escapers with different security policies — codeString escapes U+2028/U+2029; sanitizeCodeString also escapes </> to stop a </script> breakout. Which protection applies depends on which one the caller imported.
  • Python and Go have no string escaper at all — 19 and 28 raw JSON.stringify calls.
  • Two pagination resolvers implementing the same precedence rule; they can disagree about whether an operation paginates.

Reference

Follow-up to #3016. Implementation will land in separate PRs against that branch.

Testing

Documentation only; no code changed, so no tests apply. Cross-document links and every cited file:line were verified against the source.

Note: the pre-commit hook was bypassed. npm run lint currently fails on ~200 pre-existing files across all packages because the local node_modules has oxlint@1.79.0 against a declared ^1.48.0, so newer rules fire. Zero errors are in the files this PR touches.

Screenshots (optional)

Check yourself

  • This PR follows the contributing guide
  • All new/updated code is covered by tests
  • Core code changed? - Tested with other Redocly products (internal contributions only)
  • New package installed? - Tested in different environments (browser/node)
  • Documentation update has been considered

Security

  • The security impact of the change has been considered
  • Code follows company security practices and guidelines

No code changes, so no direct security impact. The analysis does document two
existing security-relevant issues for the rewrite to fix: the divergent
TypeScript string-escaping policies (</script> breakout protection applies
only on one path) and the absence of any defined escaping policy in the Python
and Go generators.

🤖 Generated with Claude Code


Note

Low Risk
Documentation only; no runtime or API code changes. The ADRs do commit to later breaking removals of package-mode runtime.

Overview
Adds design docs for the planned client-generator rewrite: three accepted ADRs plus a point-in-time helper inventory. No implementation.

ADR-0020 makes every generator a self-contained folder ejected as TypeScript source (no esbuild bundle or import rewrite). ADR-0021 supersedes ADR-0001: codegen is text via a structural Printer plus per-language syntax printers. ADR-0022 amends ADR-0017 by dropping runtime: 'package' in favor of 'inline' | 'module'.

helper-surface.md measures sharing (TS-family generators share four functions / 27 lines) and catalogs twelve duplications, including divergent string escapers and two pagination resolvers. The ADR index marks 0001 superseded and 0017 amended.

Reviewed by Cursor Bugbot for commit 75f9e00. Bugbot is set up for automated code reviews on this repo. Configure here.

…alyze the helper surface

Three ADRs for the self-contained generator rewrite, plus the measured
inventory they rest on.

ADR-0020 makes every generator a self-contained folder ejected as source,
replacing the esbuild bundle that inlines already-public toolkit code.
ADR-0021 records the text-printer architecture the code already uses and
supersedes ADR-0001, which still documents the removed ts.factory codegen.
ADR-0022 drops runtime: package for a sibling runtime module, and amends
ADR-0017 point 3.

helper-surface.md catalogues twelve duplications with file:line evidence.
Three are defects rather than untidiness: two TypeScript string escapers
with different escaping policies, no escaper at all in python and go, and
two pagination resolvers that can disagree about whether an operation
paginates.
@RomanHotsiy
RomanHotsiy requested a review from a team as a code owner August 21, 2026 06:41
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8d994d2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@redocly/openapi-core Patch
@redocly/cli Patch
@redocly/client-generator Patch
@redocly/respect-core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@RomanHotsiy
RomanHotsiy marked this pull request as draft August 21, 2026 06:43
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 78.3% (🎯 77%) 13753 / 17564
🔵 Statements 78.08% (🎯 77%) 14804 / 18958
🔵 Functions 82.02% (🎯 81%) 2824 / 3443
🔵 Branches 71.28% (🎯 71%) 10164 / 14258
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/commands/eject-generator.ts 42.08% 32.46% 43.24% 42.61% 72, 79-135, 172-175, 191, 210, 224, 226, 227, 236, 250-325, 346, 367, 382-384, 396, 398, 404, 410, 449-624, 671-692, 696-699, 703-759
packages/cli/src/commands/generate-client.ts 24.29% 17.64% 8.33% 23.76% 52-285
packages/client-generator/src/cli-contract.ts 100% 100% 100% 100%
packages/client-generator/src/generate.ts 100% 100% 100% 100%
packages/client-generator/src/index.ts 100% 100% 100% 100%
packages/client-generator/src/pipeline.ts 93.02% 82.45% 100% 93.97% 83-84, 89-90, 122-124, 187
packages/client-generator/src/plugin.ts 100% 100% 100% 100%
packages/client-generator/src/runtime-contract.ts 100% 100% 100% 100%
packages/client-generator/src/authoring/index.ts 100% 100% 100% 100%
packages/client-generator/src/authoring/naming.ts 100% 77.77% 100% 100%
packages/client-generator/src/authoring/operation.ts 97.67% 88.46% 100% 100% 110
packages/client-generator/src/authoring/pagination.ts 100% 90% 100% 100%
packages/client-generator/src/authoring/reference-page.ts 93.47% 79.31% 100% 92.94% 53, 59, 66, 70-74
packages/client-generator/src/authoring/schema.ts 76.74% 72% 80.95% 82.02% 39, 65, 105, 123, 168-172, 175-183, 188, 192, 196-199
packages/client-generator/src/generators/index.ts 100% 100% 100% 100%
packages/client-generator/src/generators/meta.ts 82.22% 100% 65.21% 88.23% 20-22, 55-73
packages/client-generator/src/generators/resolve.ts 98.27% 96.15% 100% 98.14% 171-173
packages/client-generator/src/generators/cli/engine-source.ts 50% 100% 50% 50% 16
packages/client-generator/src/generators/cli/index.ts 60% 75% 50% 68.75% 31-38, 48-54, 62, 68
packages/client-generator/src/generators/go/client.ts 95% 92.85% 100% 100% 30
packages/client-generator/src/generators/go/descriptor.ts 100% 57.14% 100% 100%
packages/client-generator/src/generators/go/index.ts 97.32% 85.24% 83.33% 98.03% 275, 301-313
packages/client-generator/src/generators/go/models.ts 95.12% 87.09% 100% 96.25% 96, 170-172
packages/client-generator/src/generators/go/naming.ts 94.44% 100% 100% 100% 39
packages/client-generator/src/generators/go/operations.ts 96.33% 88% 100% 100% 27, 29, 30, 70
packages/client-generator/src/generators/go/pagination.ts 96.55% 50% 91.3% 96.55% 23, 68
packages/client-generator/src/generators/go/types.ts 82.35% 61.29% 100% 80% 35-48
packages/client-generator/src/generators/mock/index.ts 100% 100% 100% 100%
packages/client-generator/src/generators/php/client.ts 95.65% 91.66% 100% 100% 39
packages/client-generator/src/generators/php/descriptor.ts 94.73% 68.75% 100% 94.44% 48
packages/client-generator/src/generators/php/index.ts 97.59% 78.04% 83.33% 98.68% 202-214
packages/client-generator/src/generators/php/models.ts 92.3% 78.02% 100% 92.43% 42, 48-49, 57-59, 80, 90-91
packages/client-generator/src/generators/php/naming.ts 91.66% 100% 87.5% 90% 32
packages/client-generator/src/generators/php/operations.ts 100% 96.96% 100% 100%
packages/client-generator/src/generators/php/pagination.ts 97.82% 50% 91.66% 97.82% 44
packages/client-generator/src/generators/php/types.ts 88.13% 76% 100% 95.83% 20, 66-73, 95, 110, 116, 118
packages/client-generator/src/generators/python/client.ts 98% 90% 100% 100% 44
packages/client-generator/src/generators/python/descriptor.ts 93.33% 57.14% 100% 100% 42
packages/client-generator/src/generators/python/index.ts 91.46% 75.6% 70.58% 92.1% 194-201, 217-229
packages/client-generator/src/generators/python/models.ts 95.96% 88.88% 100% 98.13% 52-53, 65, 68, 227
packages/client-generator/src/generators/python/naming.ts 100% 100% 100% 100%
packages/client-generator/src/generators/python/operations.ts 100% 94.11% 100% 100%
packages/client-generator/src/generators/python/pagination.ts 100% 92.3% 100% 100%
packages/client-generator/src/generators/python/types.ts 78.94% 78.26% 100% 75% 33-36, 40-44
packages/client-generator/src/generators/swr/index.ts 100% 100% 100% 100%
packages/client-generator/src/generators/tanstack-query/index.ts 100% 100% 100% 100%
packages/client-generator/src/generators/transformers/index.ts 100% 100% 100% 100%
packages/client-generator/src/generators/typescript/banner.ts 100% 100% 100% 100%
packages/client-generator/src/generators/typescript/index.ts 75% 54.54% 25% 94.73% 24-27, 47-59, 72, 74, 75
packages/client-generator/src/generators/zod/index.ts 100% 100% 100% 100%
packages/client-generator/src/intermediate-representation/build.ts 100% 99.47% 100% 100%
packages/client-generator/src/intermediate-representation/model.ts 100% 100% 100% 100%
packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts 100% 100% 100% 100%
packages/client-generator/src/printers/go.ts 86.36% 88.09% 77.27% 90.1% 26-31, 65, 67, 76, 77, 79-91
packages/client-generator/src/printers/php.ts 50% 34.78% 45.45% 56% 27-32, 42-55
packages/client-generator/src/printers/python.ts 83.63% 81.57% 83.33% 91.89% 30, 56, 67, 68, 69, 70, 81-82
packages/client-generator/src/printers/typescript.ts 82.24% 81.33% 87.5% 92.77% 19-24, 50-56, 62, 218, 225, 226, 227, 228, 229, 230, 231, 232, 234, 241, 242
packages/core/src/types/asyncapi3.ts 61.9% 21.42% 33.33% 61.9% 186, 254, 483-511
packages/core/src/types/redocly-yaml.ts 89.32% 77.35% 92.85% 89% 491, 523, 529, 573-580, 582, 720-730, 740-756
Generated in workflow #11454 for commit 8d994d2 by the Vitest Coverage Report Action

RomanHotsiy and others added 25 commits August 21, 2026 14:47
…actored too

python, go, and php are already self-contained, so ADR-0020 could be read as
leaving them alone — the split was mentioned only as a migration cost. Make it
a decision: self-containment was never the goal on its own, and leaving a
953-line python and a 1169-line go whole would keep the asymmetry the ADR
removes.

Both docs now show the re-grouping is not a rewrite: the existing functions in
all three generators sort into the same stages as they are.
Each reproduced before it was fixed, each with a regression test.

An enum value of `-1` produced `VALUE_-1 = -1` — a SyntaxError that made the whole
generated Python module unusable. `enumValues` now routes every value through
`casing` (which already knew `MINUS_1`) and `uniqueIdentifiers`, so decimals,
values that fold to one name, and the empty string all stay usable. Go and PHP
built their own member names with the same folding flaw (`1.5` and `15` both
became `15`) and dedupe per enum now, with plain integers keeping their old
spelling.

PHP fataled on two operationIds that camel-case alike (`get-user`, `getUser` →
`Cannot redeclare Client::getUser()`). It now derives one deduped name per
operation, like Python and Go already did. The sample hooks in all three languages
used the raw name, so on a collision the snippet called a method that goes to a
different operation — they read the deduped map now, and `goSample` also matches
its assignment to the return shape (`err :=` for void, `stream :=` for SSE, where
`result, err :=` does not compile).

A Go array query param went through `fmt.Sprint`, putting `?tags=[a b]` on the
wire as one value. An array now repeats the key per element — proven with a real
`httptest` server: `tags=a&tags=b`.

`register()` copied seven fields and dropped `docs` and `notApplicable`, so an
ejected generator quietly did less than the built-in it replaced: `--docs` wrote
no page, ignored options stopped warning.

Two dropped-`dateType` call sites: PHP's top-level hydration returned a raw string
where its own signature declared `\DateTimeImmutable`, and Python's iterator
signatures said `str` where the method beside them said `datetime`. The defaulted
parameter was the trap, so PHP's `hydration` now requires it.
…o to the toolkit

Which response is the JSON success, whether an operation streams, and whether its
body is multipart were answered by identical private functions in python, go, and
php — and shipped three more times in the eject assets. They are one answer each
now: `jsonSuccessSchema`, `sseResponse`, and `isMultipartBody` in a new
`authoring/operation.ts`, so two generators cannot disagree about the same
operation, and a user-authored generator gets them instead of re-deriving them.

`deref` goes public with them: `authoring/schema.ts` kept it private, and php had
re-implemented it line for line.
…oolkit

`serverUrlExpression` was copied character-for-character into python, go, and php —
same regex loop, same undeclared-variable comment — differing only in how a literal
is quoted and parts are joined. The parsing is `serverUrlParts(server)` in the
toolkit now, returning literal/variable parts, and each language keeps the five
lines that are genuinely its own syntax.
…he toolkit

The scheme-key → {scheme, kind, name, in} mapping existed four times — python, go,
php, and the TypeScript descriptor — with the same OR-alternatives/AND-sets shape
and the same drop-unknown-scheme rule. It is `securityRequirements(op, model)` in
the toolkit now; each consumer keeps only its own literal syntax.
…once

The `schemaAtPointer` → is-it-an-array → take-the-raw-element block existed in
python, go, and php with the same comment about why the element must stay a `ref`.
It is `paginationItemSchema(pageSchema, itemsPointer, model)` in the toolkit now.

The envelope-header plan stays per language on purpose: its key naming carries
per-language knowledge (Go's digit-leading `N` rule, where a `_` prefix would make
the field unexported and invisible to encoding/json) that the printers own in the
next stage — promoting it here would have traded three small copies for one wrong
abstraction.
…dupe loop, no phantom parameter

The 46-word TypeScript reserved list existed twice and had to be hand-synced —
`emitters/identifier.ts` now reads `RESERVED_WORDS.typescript` (verified identical
before merging). Python's `operationIdents` re-implemented `uniqueIdentifiers`,
which the file already imported; it is a call to it now. Go keeps its own loop on
purpose: its names go through `exported`, whose digit-leading `N` rule
`identifierFor` does not know. And `paginationRuleFor` loses the `_model` parameter
nothing ever passed.
…021)

The common `Printer` keeps structure; four new printers own each language's
syntax — identifier safety, string escaping, literal rendering, comment and doc
form, the indent unit, and (for Go) the layout pass `toString()` applies. They
fill the same slots, which is the check that the abstraction is real: `typeName`,
`memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`.

The per-language knowledge moves with them rather than being flattened: Go's
digit-leading `N` rule (a `_` prefix means unexported, so encoding/json would
silently skip the field) and its gofmt column alignment, Python's `memberName`
reporting a rename for `_field_map`, PHP's `@tag` doc form, TypeScript's
bare-or-quoted `key`. Python and Go gain a real `string()` policy — controls
escaped, non-ASCII raw, a lone surrogate spelled (`\uXXXX`) in Python and
replaced (U+FFFD) in Go, which cannot represent one — with call sites adopting it
in the next change.

python, go, and php now construct their printer and delegate naming, escaping,
and doc comments to it; their generated output is byte-identical (verified on two
fixtures each). The dogfooding guard gains the printer as a sharing tier — each
generator may import its OWN language's printer, never another's — and the eject
build rewrites the import to `@redocly/client-generator/printers/<language>`,
which is a new public subpath.
…ing policy

Both languages built string literals with `JSON.stringify` — 18 sites in python,
28 in go — relying on JSON escaping being close enough to each language's syntax.
It is not, at the edges that matter: JSON encodes an astral character (any emoji)
as a surrogate PAIR, which Go rejects outright (`\uD83C` is an invalid code point
to the compiler) and Python parses as two lone surrogates instead of the character.
A description or parameter name with an emoji broke the generated Go module and
corrupted the Python one.

Every site now goes through the printer's `string()` — controls escaped,
non-ASCII written as itself (generated files are UTF-8), a lone surrogate spelled
`\uXXXX` in Python and replaced with U+FFFD in Go, which has no spelling for one.
`pythonLiteral` delegates to the printer's `literal`, which also fixes booleans in
`Literal[...]` and enum members: JSON's lowercase `true` was never valid Python.

Output for ordinary specs is byte-identical (verified on two fixtures per
language); a spec with `"mood 🎉"` parameter and emoji enum values now
py_compiles and `go build`s.
… policy

Two escapers existed with different security policies: `codeString` escaped
U+2028/U+2029, `sanitizeCodeString` also escaped `<`/`>` to stop a `</script>`
breakout when generated output lands in an inline script. Which protection applied
depended on which one the caller imported. There is one policy now — the stricter
one, owned by `codeString` (the printers' `string()` and `sanitizeCodeString` are
the same function) — so `<` and `>` are escaped in the few places that previously
left them literal. The cafe fixture's output is unchanged; the injection e2e and
the golden snapshots pass as they are.
…, transformers, cli) into their generator folders
…de, DateType) to the generator toolkit types
… publish the SDK ABI as contracts/typescript
…, jsdoc, support) into the TypeScript printer module
…ifier, resolved to src by tsconfig paths and a vitest alias
@Marshevskyy Marshevskyy changed the title docs(client-generator): ADRs and helper-surface analysis for the generator rewrite feat(client-generator)!: self-contained generator folders, module runtime, and eject as source Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants